iOS 开发中的性能优化

由浅入深,从基本概念到源码解析,再到实际项目应用,带你全面掌握 iOS 性能优化之道


一、什么是性能优化?

1.1 为什么性能很重要?

在移动端,性能直接关系到用户体验:

指标 用户感知 业务影响
启动速度 3 秒内无法进入应用,约 77% 用户会放弃 流失、留存下降
界面卡顿 掉帧、滑动不跟手 评价差、卸载
内存占用 应用被系统强杀、白屏 体验中断、投诉
耗电发热 续航变短、设备发烫 用户反感

苹果对 App Store 的审核和推荐也会考虑应用质量,性能是重要维度之一。

1.2 性能优化的核心目标

  • :启动快、响应快、界面流畅
  • :省内存、省电、省流量
  • :不崩溃、不卡死、不白屏

1.3 性能优化的「黄金法则」

先测量,再优化;先瓶颈,再细节。

盲目优化往往事倍功半。正确的做法是:用工具定位瓶颈,再针对性地优化。


二、性能指标与测量工具

2.1 关键指标

指标 说明 理想值
FPS 帧率,60fps 为流畅 ≥ 55fps
主线程耗时 单次任务在主线程的耗时 < 16ms(一帧)
启动时间 冷启动/热启动到首屏可交互 冷启动 < 2s
内存占用 常驻内存、峰值内存 视业务而定,避免持续增长
CPU 占用 主线程 CPU 占比 空闲时尽量低

2.2 官方工具:Instruments

Instruments 是 Xcode 自带的性能分析工具套件:

  • Time Profiler:CPU 耗时分析,定位主线程卡顿
  • Allocations:内存分配追踪
  • Leaks:内存泄漏检测
  • Core Animation:离屏渲染、图层混合检测
  • Energy Log:耗电分析
  • Network:网络请求分析

2.3 第三方工具与库

工具 用途 特点
YYFPSLabel 实时 FPS 显示 开发阶段监控
MLeaksFinder 内存泄漏检测 无侵入、自动化
Matrix(微信) 综合性能监控 线上 APM
DoraemonKit 开发调试面板 多维度自检

2.4 简单 FPS 监控实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 基于 CADisplayLink 的 FPS 监控
class FPSMonitor {
private var displayLink: CADisplayLink?
private var lastTime: CFTimeInterval = 0
private var count: Int = 0
var fpsUpdate: ((Int) -> Void)?

func start() {
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .common)
}

@objc private func tick(_ link: CADisplayLink) {
if lastTime == 0 {
lastTime = link.timestamp
return
}
count += 1
let delta = link.timestamp - lastTime
if delta >= 1.0 {
let fps = Int(round(Double(count) / delta))
fpsUpdate?(fps)
count = 0
lastTime = link.timestamp
}
}

func stop() {
displayLink?.invalidate()
displayLink = nil
}
}

三、UI 与渲染优化

3.1 离屏渲染(Offscreen Rendering)

离屏渲染 是指 GPU 在当前屏幕缓冲区之外新开缓冲区进行渲染,再合成到主缓冲区的过程。额外的缓冲区和上下文切换会带来性能开销。

常见触发离屏渲染的属性:

属性 说明
cornerRadius + masksToBounds 圆角裁剪
shadow(阴影) 需要额外 Pass 计算
mask(遮罩) 蒙版合成
group opacity 组透明度
edge antialiasing 抗锯齿

优化方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 容易触发离屏渲染
imageView.layer.cornerRadius = 10
imageView.layer.masksToBounds = true

// ✅ 方案一:只对需要圆角的内容做裁剪,避免整层
imageView.layer.cornerRadius = 10
imageView.layer.masksToBounds = true
imageView.clipsToBounds = true // 对 UIImageView 而言,用 clipsToBounds 配合 contentMode

// ✅ 方案二:用贝塞尔路径 + CAShapeLayer 做圆角(iOS 9+ 可考虑)
let path = UIBezierPath(roundedRect: bounds, cornerRadius: 10)
let mask = CAShapeLayer()
mask.path = path.cgPath
layer.mask = mask // 仍可能离屏,需实测

// ✅ 方案三:直接用圆角图片(切图或 Core Graphics 绘制)
// 在子线程绘制圆角图片,主线程只做 display

阴影优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 阴影 + 裁剪容易离屏
view.layer.shadowOpacity = 0.5
view.layer.cornerRadius = 10
view.layer.masksToBounds = true // 与 shadow 冲突

// ✅ 分到两个 layer:容器负责阴影,子 layer 负责圆角
let containerView = UIView()
containerView.layer.shadowOpacity = 0.5
containerView.layer.shadowRadius = 4
containerView.layer.shadowOffset = .zero

let contentView = UIView()
contentView.layer.cornerRadius = 10
contentView.layer.masksToBounds = true
contentView.frame = containerView.bounds
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
containerView.addSubview(contentView)

3.2 图层混合(Layer Blending)

当多个图层叠在一起且存在透明像素时,GPU 需要进行混合计算。减少透明区域和图层数量可以降低开销。

优化建议:

  • 给不透明的视图设置 layer.opaque = true(或 isOpaque = true
  • 避免不必要的半透明叠加
  • 减少视图层级
1
2
3
// 已知不透明时
view.layer.opaque = true
view.backgroundColor = .white // 明确不透明色

3.3 TableView / CollectionView 优化

列表是 App 中最常见的性能瓶颈场景。

核心思路:

  1. Cell 复用:使用 dequeueReusableCell,避免重复创建
  2. 减少主线程工作:图片解码、复杂计算放到子线程
  3. 按需加载:快速滑动时减少或暂停非可见 Cell 的加载
  4. 高度缓存UITableViewAutomaticDimension 会反复计算,可缓存高度

示例:Cell 配置优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ❌ 在主线程做重活
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MyCell
let model = dataSource[indexPath.row]
cell.imageView?.image = UIImage(contentsOfFile: model.imagePath) // 同步读盘 + 解码
cell.label.text = heavyCompute(model) // 复杂计算
return cell
}

// ✅ 异步加载图片 + 计算放子线程
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MyCell
let model = dataSource[indexPath.row]
cell.tag = indexPath.row
cell.label.text = nil
cell.imageView?.image = nil

DispatchQueue.global().async {
let image = self.loadImage(path: model.imagePath)
let text = self.heavyCompute(model)
DispatchQueue.main.async {
if cell.tag == indexPath.row {
cell.imageView?.image = image
cell.label.text = text
}
}
}
return cell
}

预加载与 RunLoop 空闲优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 利用 RunLoop 在空闲时预加载
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = ...
preloadIfNeeded(at: indexPath)
return cell
}

private func preloadIfNeeded(at indexPath: IndexPath) {
let maxIndex = min(indexPath.row + 5, dataSource.count - 1)
for i in (indexPath.row + 1)...maxIndex {
if !imageCache.isCached(for: dataSource[i].imagePath) {
DispatchQueue.global().async {
_ = self.loadImage(path: self.dataSource[i].imagePath)
}
}
}
}

3.4 图片加载与解码优化

图片解码是 CPU 密集型操作,大图在主线程解码会导致卡顿。

1
2
3
4
5
6
7
8
9
10
11
// 在子线程解码
func decodeImage(_ image: UIImage) -> UIImage? {
UIGraphicsBeginImageContextWithOptions(image.size, true, 0)
image.draw(at: .zero)
let decoded = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return decoded
}

// 或使用 ImageIO 指定解码选项
// 对于网络图片,使用 SDWebImage / Kingfisher 等库,它们会在后台解码

四、内存优化

4.1 内存管理基础

  • 引用计数:OC 使用 MRC/ARC,Swift 使用 ARC
  • AutoreleasePool:自动释放池,延迟 release
  • 循环引用:block、delegate、闭包持有 self 未使用 weak 导致

4.2 AutoreleasePool 与 RunLoop

主线程 RunLoop 每次循环会创建并销毁一次 @autoreleasepool,因此临时对象会在一次循环结束释放。子线程若没有 RunLoop,需要手动加 @autoreleasepool,否则临时对象会堆积到线程结束。

1
2
3
4
5
6
7
8
9
10
// 子线程大量创建临时对象时
dispatch_async(dispatch_get_global_queue(0, 0), ^{
@autoreleasepool {
for (int i = 0; i < 10000; i++) {
// 创建大量临时对象
NSString *temp = [NSString stringWithFormat:@"item_%d", i];
[array addObject:temp];
}
}
});

objc4 源码中的 AutoreleasePoolPage 结构(简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// objc4 源码简化
class AutoreleasePoolPage {
magic_t const magic;
id *next; // 下一个可存放 autorelease 对象的地址
pthread_t const thread; // 所属线程
AutoreleasePoolPage * const parent;
AutoreleasePoolPage *child;
// ...
static void *operator new(size_t size) {
return malloc_zone_memalign(malloc_default_zone(), SIZE, SIZE);
}
id *add(id obj) {
// 将 obj 加入当前 page,next 指向下一个空位
// ...
}
static void releaseAll() {
// 从 last 到 next 逆序 release
}
};

4.3 循环引用与 weak/strong

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ block 强引用 self,self 强引用持有 block 的成员
class ViewController: UIViewController {
var onComplete: (() -> Void)?
func setup() {
onComplete = {
self.doSomething() // 强引用 self
}
}
}

// ✅ weak self
onComplete = { [weak self] in
self?.doSomething()
}

// ✅ weak + strong 避免 block 执行期间 self 被释放
onComplete = { [weak self] in
guard let self = self else { return }
self.doSomething()
}

4.4 大对象与图片内存

一张 1000×1000 的 RGBA 图片,解码后约占 约 4MB 内存。使用 UIImage(named:) 会缓存,大图慎用。

1
2
3
4
5
6
7
8
9
// 大图使用 imageWithContentsOfFile 或 UIImage(contentsOfFile:) 避免缓存
let image = UIImage(contentsOfFile: path)

// 或使用 ImageIO 进行缩略图解码,减少内存
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceThumbnailMaxPixelSize: 200
]
// 只解码缩略图尺寸,而非整张大图

五、启动优化

5.1 启动阶段

阶段 说明 可优化点
pre-main dyld 加载、ObjC 初始化、+load、C++ 静态构造 减少 +load、精简动态库
post-main main 到首屏可交互 异步化、延迟加载

5.2 pre-main 优化

1
2
# 测量 pre-main 时间:Edit Scheme → Run → Arguments → Environment Variables
# 添加 DYLD_PRINT_STATISTICS = 1
  • 减少动态库数量:合并动态库,能用静态库则用静态库
  • 减少 +load:把逻辑迁移到 +initialize 或首屏使用再初始化
  • 减少 ObjC 类/方法数量:删除无用代码,用 Swift 替代部分 OC

5.3 post-main 优化

1
2
3
4
5
6
7
8
9
10
11
// 串行改并行
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) -> Bool {
// 可并行的初始化
DispatchQueue.global().async { initAnalytics() }
DispatchQueue.global().async { initCrashReporter() }
DispatchQueue.global().async { initNetworkConfig() }

// 必须主线程且阻塞首屏的,尽量后置或精简
setupWindow()
return true
}

延迟加载:

1
2
3
4
5
6
7
8
// 非首屏必需的模块,等首屏展示后再初始化
DispatchQueue.main.async {
self.window?.rootViewController = MainTabBarController()
DispatchQueue.main.async {
// 首屏渲染完成后再做
initThirdPartySDK()
}
}

六、网络与 I/O 优化

6.1 网络请求优化

  • 合并请求、减少请求次数
  • 使用 HTTP/2 多路复用
  • 合理设置超时与重试
  • 大文件使用断点续传
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 请求合并示例(伪代码)
class RequestMerger {
private var pendingRequests: [String: [CompletionHandler]] = [:]
private var inflight: [String: URLSessionTask] = [:]

func fetch(key: String, completion: @escaping (Data?) -> Void) {
if let task = inflight[key] {
// 合并到同一请求的回调
pendingRequests[key, default: []].append(completion)
return
}
let task = URLSession.shared.dataTask(with: url) { data, _, _ in
let handlers = self.pendingRequests.removeValue(forKey: key) ?? []
DispatchQueue.main.async {
handlers.forEach { $0(data) }
}
}
task.resume()
inflight[key] = task
}
}

6.2 文件 I/O 优化

  • 避免在主线程做大量读写
  • 小文件合并、大文件分片
  • 使用 mmap 映射大文件
  • 合理使用 Data(contentsOf:) 与流式读取
1
2
3
4
5
6
7
8
9
10
11
// 大文件流式读取
if let stream = InputStream(fileAtPath: path) {
stream.open()
defer { stream.close() }
let bufferSize = 1024 * 64
var buffer = [UInt8](repeating: 0, count: bufferSize)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: bufferSize)
// 处理 buffer
}
}

七、多线程与 GCD 优化

7.1 主线程减压

任何耗时操作都不应阻塞主线程超过 16ms(约一帧)。

1
2
3
4
5
6
DispatchQueue.global(qos: .userInitiated).async {
let result = expensiveComputation()
DispatchQueue.main.async {
self.updateUI(with: result)
}
}

7.2 线程爆炸与串行化

过多并发会导致线程爆炸,反而不利于性能。可使用串行队列 + 多队列分组:

1
2
3
// 为不同任务类型使用不同队列,避免单一队列过长
let imageQueue = DispatchQueue(label: "com.app.image", qos: .userInitiated)
let dbQueue = DispatchQueue(label: "com.app.db", qos: .utility)

7.3 避免锁竞争

1
2
3
4
5
6
7
8
9
10
11
12
13
// 读多写少场景,可用 dispatch_barrier 优化
class ThreadSafeArray<Element> {
private var array: [Element] = []
private let queue = DispatchQueue(label: "com.app.safe", attributes: .concurrent)

func append(_ element: Element) {
queue.async(flags: .barrier) { self.array.append(element) }
}

var last: Element? {
queue.sync { array.last }
}
}

八、实际项目应用案例

8.1 案例一:电商首页 Feed 列表卡顿

现象:首页信息流快速滑动时明显卡顿,FPS 掉到 40 以下。

排查

  1. Time Profiler 发现 cellForRow 内存在 UIImage(contentsOfFile:) 同步解码
  2. Core Animation 发现 Cell 内圆角 + 阴影组合触发离屏渲染

优化措施

  1. 图片改为异步加载 + 子线程解码,使用 Kingfisher 的 downsamplingImageProcessor
  2. 圆角改为用 UIBezierPath 绘制圆角图,或用 cornerRadius 仅作用在 imageView.layer
  3. 高度缓存,避免 UITableViewAutomaticDimension 反复计算

效果:滑动 FPS 稳定在 58–60。


8.2 案例二:App 冷启动超 3 秒

现象:从点击图标到首屏出现超过 3 秒。

排查

  1. 通过 DYLD_PRINT_STATISTICS 发现 pre-main 约 1.2s
  2. 发现 20+ 个动态库、多个 +load 中做了同步网络请求和大量注册

优化措施

  1. 合并部分动态库,能静态链接的改为静态
  2. 移除 +load 中的网络请求和耗时逻辑,改为首屏展示后异步初始化
  3. 路由注册从「启动全量注册」改为「首次使用时按需注册」

效果:pre-main 降至约 0.6s,整体冷启动约 1.8s。


8.3 案例三:内存持续增长被系统强杀

现象:在某个二级页面反复进出多次后,App 被系统强杀。

排查

  1. Allocations 发现每次进入页面,ViewModelNetworkManager 持续增长
  2. Leaks 未报明显泄漏,但 MLeaksFinder 提示 ViewController 未释放

根因

  • NetworkManager 持有请求的 closureclosure 捕获了 ViewController
  • ViewController 又持有 NetworkManager 的 delegate,形成循环引用

优化措施

  1. 所有回调使用 [weak self],并在回调内 guard let self
  2. NetworkManager 的 delegate 改为 weak
  3. 请求完成后主动置空 completion,避免长生命周期持有

效果:反复进出页面,内存稳定回收,不再被强杀。


九、性能优化清单(自检表)

类别 检查项
UI 是否避免不必要的离屏渲染?图层是否过多?是否在子线程解码图片?
列表 Cell 是否复用?高度是否缓存?是否做了预加载?
内存 是否存在循环引用?大图是否控制解码尺寸?
启动 动态库数量是否可控?+load 是否精简?是否延迟非必要初始化?
网络 是否合并请求?超时和重试是否合理?
线程 耗时操作是否在子线程?是否存在锁竞争或线程爆炸?

十、小结

性能优化是一个持续的过程,需要:

  1. 建立指标体系:用 FPS、启动时间、内存等量化指标
  2. 善用工具:Instruments、APM、自研监控
  3. 由瓶颈入手:先解决主要矛盾,再优化细节
  4. 平衡取舍:在开发成本、可维护性和性能之间找平衡
  5. 回归验证:每次改动后做回归测试,避免引入新问题

掌握原理、熟练使用工具、结合业务实践,才能在真实项目中持续提升 App 的性能与体验。

Author

Felix Tao

Posted on

2019-06-20

Updated on

2022-03-28

Licensed under